上一篇我們整理了資料可能存在的不同位置:
UI
↓
Frontend Data
↓
Browser Storage / API
↓
Backend
↓
Database
接下來,如果把前面的備忘錄搬到 React,會發現寫法突然變得很不一樣。
原本使用 JavaScript 時,我們可能會自己建立一個 <li>:
const li = document.createElement("li");
li.textContent = text;
memoList.appendChild(li);
但到了 React,卻開始看到:
const [memos, setMemos] = useState<string[]>([]);
以及:
{memos.map(memo => (
<li>{memo}</li>
))}
我一開始最大的疑問不是:
useState語法到底怎麼背?
而是:
我明明只是想讓畫面多一筆資料,為什麼 React 不直接讓我建立一個
<li>?
前面的備忘錄,可以想成這樣:
使用者輸入內容
↓
按下 +
↓
取得 input 的內容
↓
建立 <li>
↓
放進畫面
例如:
addBtn.addEventListener("click", () => {
const text = memoInput.value.trim();
if (!text) return;
const li = document.createElement("li");
li.textContent = text;
memoList.appendChild(li);
});
這裡的思考方式比較像:
我現在要怎麼修改 HTML?
所以我們自己操作 DOM:
找到 DOM
↓
建立 DOM
↓
修改 DOM
到了 React,思考方式會稍微不一樣。
我們先準備一份資料:
const [memos, setMemos] = useState<string[]>([]);
這一行如果先不管完整語法,我目前會這樣理解:
memos
→ 現在的備忘錄資料
setMemos
→ 用來更新 memos
一開始:
memos = [];
假設後來加入:
買牛奶
資料就會變成:
["買牛奶"]
而畫面可以根據 memos 顯示內容:
<ul>
{memos.map((memo, index) => (
<li key={`${memo}-${index}`}>
{memo}
</li>
))}
</ul>
所以這次不是我們自己下指令:
幫我建立一個 li
而比較像:
memos 現在有什麼資料?
↓
React 根據這份資料
↓
決定畫面應該長什麼樣子
這也是原生 JavaScript 和 React 很重要的一個思考差異:
原生 JavaScript
我直接修改畫面
React
我先更新資料
↓
React 再根據資料更新畫面
先看一個簡化版:
import { useState } from "react";
export default function Memo() {
const [text, setText] = useState("");
const [memos, setMemos] = useState<string[]>([]);
const handleAdd = () => {
const value = text.trim();
if (!value) return;
setMemos(prev => [...prev, value]);
setText("");
};
return (
<>
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={handleAdd}>
+
</button>
<ul>
{memos.map((memo, index) => (
<li key={`${memo}-${index}`}>
{memo}
</li>
))}
</ul>
</>
);
}
第一次看到這段,很容易又回到以前的習慣:
第一行背什麼?
↓
第二行背什麼?
↓
第三行為什麼又長這樣?
但如果用前幾篇的方法,就不要先背程式碼。
先追流程。
使用者輸入:
買牛奶
會先發生:
使用者輸入
↓
onChange
↓
setText()
↓
text 更新
接著按下:
+
流程變成:
onClick
↓
handleAdd()
↓
取得 text
↓
trim()
↓
setMemos()
↓
memos 更新
↓
React Render
↓
畫面出現「買牛奶」
整段程式碼就開始有一條資料流可以追。
onClick 才是這裡負責點擊事件的角色以前使用 JavaScript 時,我們可能寫:
addBtn.addEventListener("click", handleAdd);
到了 React,常見的寫法是:
<button onClick={handleAdd}>
+
</button>
所以目前可以先這樣對照:
JavaScript
addEventListener("click", handleAdd)
↓
React
onClick={handleAdd}
同樣地:
<input
onChange={e => setText(e.target.value)}
/>
代表輸入內容改變時,執行對應的 function。
所以:
使用者點擊
→ onClick
輸入內容改變
→ onChange
這和 useEffect 是不同的事情。
useEffect 之後會另外處理,先不要全部混在一起。
setMemos() 到底做了什麼?這一行:
setMemos(prev => [...prev, value]);
第一次看到也很容易卡住。
例如原本:
prev = ["買牛奶"];
這次新增:
value = "寫文章";
這裡:
[...prev, value]
會得到:
["買牛奶", "寫文章"]
所以:
setMemos(prev => [...prev, value]);
可以先理解成:
拿到原本的備忘錄,再把新的資料加進去,最後用新的 Array 更新
memos。
流程就是:
舊資料
["買牛奶"]
↓ 加入新的 value
["買牛奶", "寫文章"]
↓ setMemos()
更新 State
map() 又在做什麼?現在 memos 可能長這樣:
["買牛奶", "寫文章"]
但畫面需要的是:
<li>買牛奶</li>
<li>寫文章</li>
所以 React 裡常看到:
memos.map((memo, index) => (
<li key={`${memo}-${index}`}>
{memo}
</li>
))
可以把它想成:
["買牛奶", "寫文章"]
↓
一筆一筆處理
買牛奶
↓
<li>買牛奶</li>
寫文章
↓
<li>寫文章</li>
所以我後來比較不會只把 map() 背成:
一個迴圈。
在這個例子裡,更接近:
把 Array 裡的資料轉換成一份可以 Render 的內容。
key這裡為了讓範例維持簡單:
key={`${memo}-${index}`}
只是暫時讓每一筆 Render 的內容有可以區分的 key。
實際專案中,如果資料本身有:
id
通常會優先使用穩定且唯一的 ID,例如:
<li key={memo.id}>
而不是依賴 index。
這個之後遇到列表實務問題時,再另外拆會比較清楚。
備忘錄很簡單,但到了真正的 React 專案,本質上還是同一件事。
例如列表頁:
API
↓
Response
↓
更新 State
↓
State 傳給 Table
↓
React Render
↓
畫面出現資料
所以當畫面上的資料有問題,我現在會開始反過來找:
這個 Table 現在吃哪份資料?
↓
這份資料來自哪個 State?
↓
State 是誰更新的?
↓
資料是 Props 傳進來的?
還是 API Response?
而不是一看到 UI 有問題,就直接從 JSX 開始修改。
useState 的格式如果只記:
const [memos, setMemos] = useState([]);
過一段時間還是可能忘記。
但如果先理解:
State
↓
現在畫面依賴的資料
使用者操作
↓
事件
↓
更新 State
↓
React Render
↓
UI 跟著資料改變
再看到:
useState
setMemos
onClick
onChange
map
它們就不再是幾個分開的咒語。
而是同一條資料流程裡,各自負責不同事情的工具。
對我來說,這也是從原生 JavaScript 進到 React 後,一個很重要的思考轉換:
不要一直想著「我要怎麼直接修改畫面」,而是先確認「現在的資料應該是什麼」。
當資料改變後,React 再根據新的資料重新 Render UI。
下一篇就可以接著追另一個我以前也很容易混在一起的問題:
State 和 Props 到底差在哪?為什麼有些資料自己管理,有些資料卻是別人傳進來的?